# smart_exam_bot.py
# 已去除“回答不全面直接0分”逻辑
import os
import chardet
import time
import docx
import re
import numpy as np
from typing import List, Dict, Tuple
from openai import OpenAI
from collections import Counter
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


class SmartExamBot:
    def __init__(self, api_key: str, base_url: str, max_questions: int = 5):
        self.api_key = api_key
        self.base_url = base_url
        self.client = OpenAI(api_key=api_key, base_url=base_url)

        self.max_questions = max_questions
        self.question_count = 0
        self.total_score = 0
        self.max_possible_score = 0
        self.question_scores = []

        # 正则
        self.cannot_answer_patterns = re.compile(
            r'不会|不知道|不清楚|没学过|不懂|不了解|不会做|不知道怎么答|无法回答|我举不出来例子|分析不出来', re.I)
        self.irrelevant_patterns = re.compile(
            r'跑题|偏题|无关|答非所问|换个题|另一个|不相关|说点别的|其他内容|别的话题', re.I)
        self.image_patterns = re.compile(
            r'如图|图示|图片|图表|图[.\d]+|figure|graph|chart|diagram|illustration|源程序|源代码', re.I)
        self.keyword_spam_threshold = 0.15

        # RAG
        self.vectorizer = TfidfVectorizer()
        self.knowledge_vectors = None
        self.knowledge_passages = []

        # 历史
        self.generated_questions = []
        self.question_similarity_threshold = 0.6
        self.history_responses = []

        # 运行时
        self.knowledge_content = ""
        self.keywords = []
        self.current_question = ""
        self.question_dimensions = []
        self.unrelated_topics = {
            '天气', '吃饭', '电影', '游戏', '睡觉', '时间', '日期',
            '音乐', '歌曲', '明星', '旅游', '购物', '美食',
            '体育', '运动', '小说', '书籍', '电视剧', '综艺'
        }

    # ---------------- 工具 ----------------
    def _chunk_knowledge(self, content: str, chunk_size: int = 500) -> List[str]:
        words = content.split()
        return [" ".join(words[i:i + chunk_size]) for i in range(0, len(words), chunk_size)]

    def _vectorize_knowledge(self):
        if not self.knowledge_passages:
            self.knowledge_passages = self._chunk_knowledge(self.knowledge_content)
        self.vectorizer.fit(self.knowledge_passages)
        self.knowledge_vectors = self.vectorizer.transform(self.knowledge_passages)

    def retrieve_relevant_passages(self, query: str, top_k: int = 3) -> List[str]:
        if self.knowledge_vectors is None:
            self._vectorize_knowledge()
        query_vec = self.vectorizer.transform([query])
        sims = cosine_similarity(query_vec, self.knowledge_vectors)[0]
        idx = np.argsort(sims)[-top_k:][::-1]
        return [self.knowledge_passages[i] for i in idx]

    def _is_question_too_similar(self, q: str) -> bool:
        if not self.generated_questions:
            return False
        v_new = self.vectorizer.transform([q])
        for old in self.generated_questions:
            if cosine_similarity(v_new, self.vectorizer.transform([old]))[0][0] > self.question_similarity_threshold:
                return True
        return False

    def _is_response_duplicate(self, r: str) -> bool:
        if not self.history_responses:
            return False
        v_new = self.vectorizer.transform([r])
        for old in self.history_responses:
            if cosine_similarity(v_new, self.vectorizer.transform([old]))[0][0] > self.question_similarity_threshold:
                return True
        return False

    def _extract_keywords(self, text: str) -> List[str]:
        words = re.findall(r'\b[\u4e00-\u9fa5]{2,6}\b', text)
        counter = Counter(words)
        stop = {'可以', '一个', '这个', '我们', '进行', '需要', '应该', '问题', '方法'}
        kw = [w for w, c in counter.most_common(20) if w not in stop and c > 1]
        return kw[:10] or ["数据", "分析", "技术"]

    def _extract_dimensions(self, q: str) -> List[str]:
        m = re.search(r'请从(.*?)分析', q)
        if m:
            dims = re.split(r'[、和与]', m.group(1))
            return [d.strip() for d in dims if d.strip()]
        return []

    def _is_duplicating_question(self, q: str, a: str) -> bool:
        def clean(t): return re.sub(r'[^\w\s]', '',
                                    re.sub(r'请从|分析|简述|说明|论述|谈谈|如何|什么|为什么|是什么|有哪些', '', t, flags=re.I)).strip()
        cq, ca = clean(q), clean(a)
        if len(ca) < len(cq) * 0.5:
            return True
        if len(set(cq) & set(ca)) / min(len(cq), len(ca)) > 0.3:
            return True
        qset = set(re.findall(r'\b[\u4e00-\u9fa5a-zA-Z]{2,}\b', cq))
        aset = set(re.findall(r'\b[\u4e00-\u9fa5a-zA-Z]{2,}\b', ca))
        return qset and len(aset & qset) / len(qset) > 0.7

    # ---------------- 文件读取 ----------------
    def detect_encoding(self, fp: str) -> str:
        if fp.lower().endswith('.docx'):
            return 'docx'
        with open(fp, 'rb') as f:
            return chardet.detect(f.read(10240))['encoding'] or 'utf-8'

    def read_file_content(self, fp: str) -> str:
        if not os.path.exists(fp):
            raise FileNotFoundError(fp)
        if not os.path.isfile(fp):
            raise IsADirectoryError(fp)

        enc = self.detect_encoding(fp)
        if enc == 'docx':
            doc = docx.Document(fp)
            text = [p.text for p in doc.paragraphs]
            for t in doc.tables:
                text += ['\t'.join(c.text for c in r.cells) for r in t.rows]
            return '\n'.join(text)

        for e in [enc, 'utf-8', 'gbk', 'latin-1']:
            try:
                with open(fp, encoding=e, errors='replace') as f:
                    return f.read()
            except UnicodeDecodeError:
                continue
        raise Exception("无法识别编码")

    # ---------------- AI 调用 ----------------
    def get_ai_response(self, msgs: List[Dict], retries: int = 3) -> str:
        for i in range(retries):
            try:
                return self.client.chat.completions.create(
                    model="deepseek-v3",
                    messages=msgs,
                    temperature=0.7,
                    timeout=30
                ).choices[0].message.content
            except Exception as e:
                if i == retries - 1:
                    raise e
                time.sleep(2)

    # ---------------- 答题有效性 ----------------
    def is_cannot_answer(self, txt: str) -> bool:
        return self.cannot_answer_patterns.search(txt.strip()) is not None

    def is_irrelevant(self, ans: str) -> Tuple[bool, str]:
        ans = ans.strip().lower()
        if self.irrelevant_patterns.search(ans):
            return True, "回答明确表示与题目无关"
        all_kw = re.findall(r'\b[\u4e00-\u9fa5]{2,6}\b', self.current_question) + self.keywords + self.question_dimensions
        if not any(k in ans for k in all_kw):
            for t in self.unrelated_topics:
                if t in ans:
                    return True, f"回答涉及无关主题({t})"
            if len(ans) < 5:
                return True, "回答过短且不涉及题目相关内容"
            return True, "回答与题目及知识库内容无关"
        return False, ""

    # ---------------- 质量检查（不再因维度不全给0分） ----------------
    def _check_answer_quality(self, answer: str) -> Tuple[bool, float, str]:
        sentences = re.split(r'[。？！；]', answer)
        valid = [s for s in sentences if len(s) > 5 and re.search(
            r'(提供|分析|解释|说明|讨论|比较|解决|实现|应用|使用)', s)]
        if len(valid) < 2:
            return False, 0.0, "回答缺乏有效分析内容（至少需要2个完整分析句）"

        dim_cov = {d: 0 for d in self.question_dimensions}
        feedback_parts = []
        for d in self.question_dimensions:
            if d in answer:
                if re.search(fr'{d}[^，。；：,.!?]*?(原理|机制|案例|应用|方法|策略)', answer):
                    dim_cov[d] = 1.0
                    feedback_parts.append(f"维度'{d}'完整覆盖")
                else:
                    dim_cov[d] = 0.3
                    feedback_parts.append(f"维度'{d}'仅提及未展开")
            else:
                feedback_parts.append(f"维度'{d}'未提及")

        dim_score = sum(dim_cov.values()) / len(self.question_dimensions) if self.question_dimensions else 1.0
        key_cnt = sum(len(re.findall(d, answer)) for d in self.question_dimensions)
        key_density = key_cnt / len(answer.split()) if answer.split() else 0
        if key_density > self.keyword_spam_threshold:
            return False, dim_score, f"关键词堆砌严重（密度>{self.keyword_spam_threshold * 100:.0f}%）"

        return True, dim_score, " | ".join(feedback_parts)

    # ---------------- 主流程 ----------------
    def start_exam(self, content: str):
        self.knowledge_content = content
        self.keywords = self._extract_keywords(content)
        self._vectorize_knowledge()

        print("\n" + "=" * 50)
        print("智能考试机器人".center(40))
        print("=" * 50)
        print(f"\n我将根据提供的知识内容对你进行考察，共{self.max_questions}题。")
        print("输入'退出'可随时结束考试。\n")

        msgs = [{"role": "system", "content": f"""
            你是公平的智能考试机器人，知识库如下（节选）：
            {self.knowledge_content[:2000]}...
            题目必须从知识库中提取具体概念，必须包含至少1个关键词：{', '.join(self.keywords)}，
            要求从1-2个维度回答，题目多样化且不含图片/代码。
            仅输出题目，不输出评分信息。
        "}]

        while self.question_count < self.max_questions:
            self._handle_new_question(msgs)
            print("\n你: ", end="", flush=True)
            lines = []
            while True:
                try:
                    line = input().strip()
                except EOFError:
                    line = "退出"
                if line.lower() in ['退出', 'q', 'quit']:
                    self._show_final_score()
                    return
                if not line and lines:
                    user = '\n'.join(lines)
                    break
                if line:
                    lines.append(line)
                elif not lines:
                    print("请输入内容：", end="", flush=True)
            self._handle_user_answer(user, msgs)
            print(f"\n本题结束，当前进度: {self.question_count}/{self.max_questions}题")
        self._show_final_score()

    # ---------------- 题目生成 ----------------
    def _handle_new_question(self, msgs: List[Dict]):
        self.question_count += 1
        retries, question = 0, None
        while retries < 5:
            ctx = "\n".join(self.retrieve_relevant_passages(" ".join(self.keywords[:3]), 2))
            prompt = f"""请基于以下知识库内容出一道新颖主观题（避免与已出题目相似）：
            {ctx[:1500]}
            要求：
            1. 至少包含1个关键词：{', '.join(self.keywords)}
            2. 形式：“请从[维度1][和/与/、][维度2]分析[关键词]...”
            3. 题目长度>10字，不含图片/代码
            4. 仅输出题目，不输出其他内容
            """
            msgs.append({"role": "user", "content": prompt})
            question = self.get_ai_response(msgs)
            if (any(k in question for k in self.keywords) and
                len(re.findall(r'维度|角度|方面', question)) >= 1 and
                not self.image_patterns.search(question) and
                len(question) >= 10 and
                not self._is_question_too_similar(question)):
                break
            msgs.pop()
            retries += 1
        if retries >= 5 or not question:
            print("⚠️ 多次尝试后仍无法获取有效题目，跳过本题")
            self.question_count -= 1
            return
        question = re.sub(r'^\d+[.\d]*\s*', '', question.split("|")[0].strip())
        self.current_question = question
        self.question_dimensions = self._extract_dimensions(question)
        self.generated_questions.append(question)
        msgs.append({"role": "assistant", "content": question})
        print(f"\n===== 第{self.question_count}/{self.max_questions}题 =====")
        print(f"考试机器人: {question}")
        self.max_possible_score += 10

    # ---------------- 答案处理 ----------------
    def _handle_user_answer(self, ans: str, msgs: List[Dict]):
        # 重复回答
        if self._is_response_duplicate(ans):
            print("\n检测到与历史题目重复，得0分")
            self._record_score(0, "回答与历史题目高度重复")
            return
        # 不会答
        if self.is_cannot_answer(ans):
            self._record_score(0, "检测到无法回答的表述")
            return
        # 跑题
        irr, reason = self.is_irrelevant(ans)
        if irr:
            self._record_score(0, reason)
            return
        # 重复题干
        if self._is_duplicating_question(self.current_question, ans):
            self._record_score(0, "回答只是重复题目内容")
            return

        # 质量检查
        ok, dim_score, quality_fb = self._check_answer_quality(ans)
        if not ok:
            print(f"\n⚠️ 答题质量提示: {quality_fb}")

        # AI评分
        msgs.append({"role": "user", "content": ans})
        prompt = f"""
        ### 题目要求
        {self.current_question}
        要求维度：{", ".join(self.question_dimensions)}
        ### 考生回答
        {ans}
        ### 评分标准（10分制）
        1. 维度覆盖（40%）
        2. 内容深度（30%）
        3. 知识准确性（20%）
        4. 逻辑结构（10%）
        ### 输出格式
        分数：[0-10保留1位]
        维度覆盖评分：[0-4]
        内容深度评分：[0-3]
        知识准确评分：[0-2]
        逻辑结构评分：[0-1]
        反馈：[≤50字]
        """
        eval_text = self.get_ai_response([
            {"role": "system", "content": "你是专业考官，请严格按照格式评分"},
            {"role": "user", "content": prompt}
        ])
        score = 0.0
        try:
            score = float(re.search(r'分数[:：]\s*(\d+\.?\d?)', eval_text).group(1))
        except Exception:
            score = 6.0
        self._record_score(score, eval_text)

    def _record_score(self, score: float, raw: str):
        self.total_score += score
        self.question_scores.append(score)
        self.history_responses.append("")
        print(f"\n评分: {score:.1f}/10")
        print(f"反馈: {raw}")
        print(f"累计得分: {self.total_score:.1f}/{self.max_possible_score}")

    # ---------------- 结束展示 ----------------
    def _show_final_score(self):
        print("\n" + "=" * 50)
        if self.question_count == 0:
            print("本次未完成任何题目。")
            return
        pct = (self.total_score / self.max_possible_score) * 100
        print(f"\n考试结束！")